[id].vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  1. <template>
  2. <div v-if="workflow" class="flex h-screen">
  3. <div
  4. v-if="state.showSidebar"
  5. class="w-80 bg-white dark:bg-gray-800 py-6 relative border-l border-gray-100 dark:border-gray-700 dark:border-opacity-50 flex flex-col"
  6. >
  7. <workflow-edit-block
  8. v-if="editState.editing"
  9. v-model:autocomplete="autocompleteState.cache"
  10. :data="editState.blockData"
  11. :data-changed="autocompleteState.dataChanged"
  12. :workflow="workflow"
  13. :editor="editor"
  14. @update="updateBlockData"
  15. @close="(editState.editing = false), (editState.blockData = {})"
  16. />
  17. <workflow-details-card
  18. v-else
  19. :workflow="workflow"
  20. @update="updateWorkflow"
  21. />
  22. </div>
  23. <div class="flex-1 relative overflow-auto">
  24. <div
  25. class="absolute w-full flex items-center z-10 left-0 p-4 top-0 pointer-events-none"
  26. >
  27. <ui-tabs
  28. v-model="state.activeTab"
  29. class="border-none px-2 rounded-lg h-full space-x-1 bg-white dark:bg-gray-800 pointer-events-auto"
  30. >
  31. <button
  32. v-tooltip="
  33. `${t('workflow.toggleSidebar')} (${
  34. shortcut['editor:toggle-sidebar'].readable
  35. })`
  36. "
  37. style="margin-right: 6px"
  38. @click="toggleSidebar"
  39. >
  40. <v-remixicon
  41. :name="state.showSidebar ? 'riSideBarFill' : 'riSideBarLine'"
  42. />
  43. </button>
  44. <ui-tab value="editor">{{ t('common.editor') }}</ui-tab>
  45. <ui-tab value="logs" class="flex items-center">
  46. {{ t('common.log', 2) }}
  47. <span
  48. v-if="workflowStore.states.length > 0"
  49. class="ml-2 p-1 text-center inline-block text-xs rounded-full bg-accent text-white dark:text-black"
  50. style="min-width: 25px"
  51. >
  52. {{ workflowStore.states.length }}
  53. </span>
  54. </ui-tab>
  55. </ui-tabs>
  56. <div class="flex-grow pointer-events-none" />
  57. <editor-local-actions
  58. :editor="editor"
  59. :workflow="workflow"
  60. :is-data-changed="state.dataChanged"
  61. @update="onActionUpdated"
  62. @modal="(modalState.name = $event), (modalState.show = true)"
  63. />
  64. </div>
  65. <ui-tab-panels
  66. v-model="state.activeTab"
  67. class="overflow-hidden h-full w-full"
  68. @drop="onDropInEditor"
  69. @dragend="clearHighlightedElements"
  70. @dragover.prevent="onDragoverEditor"
  71. >
  72. <ui-tab-panel cache value="editor" class="w-full">
  73. <workflow-editor
  74. v-if="state.workflowConverted"
  75. :id="route.params.id"
  76. :data="workflow.drawflow"
  77. class="h-screen"
  78. @init="onEditorInit"
  79. @edit="initEditBlock"
  80. @update:node="state.dataChanged = true"
  81. @delete:node="state.dataChanged = true"
  82. />
  83. <editor-local-ctx-menu
  84. v-if="editor"
  85. :editor="editor"
  86. @copy="copySelectedElements"
  87. @paste="pasteCopiedElements"
  88. @duplicate="duplicateElements"
  89. />
  90. </ui-tab-panel>
  91. <ui-tab-panel value="logs" class="mt-24 container">
  92. <editor-logs
  93. :workflow-id="route.params.id"
  94. :workflow-states="workflowStore.states"
  95. />
  96. </ui-tab-panel>
  97. </ui-tab-panels>
  98. </div>
  99. </div>
  100. <ui-modal
  101. v-model="modalState.show"
  102. :content-class="activeWorkflowModal?.width || 'max-w-xl'"
  103. v-bind="activeWorkflowModal.attrs || {}"
  104. >
  105. <template v-if="activeWorkflowModal.title" #header>
  106. {{ activeWorkflowModal.title }}
  107. <a
  108. v-if="activeWorkflowModal.docs"
  109. :title="t('common.docs')"
  110. :href="activeWorkflowModal.docs"
  111. target="_blank"
  112. class="inline-block align-middle"
  113. >
  114. <v-remixicon name="riInformationLine" size="20" />
  115. </a>
  116. </template>
  117. <component
  118. :is="activeWorkflowModal.component"
  119. v-bind="{ workflow }"
  120. v-on="activeWorkflowModal?.events || {}"
  121. @update="updateWorkflow"
  122. @close="modalState.show = false"
  123. />
  124. </ui-modal>
  125. </template>
  126. <script setup>
  127. import {
  128. provide,
  129. reactive,
  130. computed,
  131. onMounted,
  132. shallowRef,
  133. onBeforeUnmount,
  134. } from 'vue';
  135. import { useI18n } from 'vue-i18n';
  136. import { useRoute, useRouter, onBeforeRouteLeave } from 'vue-router';
  137. import { customAlphabet } from 'nanoid';
  138. import defu from 'defu';
  139. import { useStore } from '@/stores/main';
  140. import { useUserStore } from '@/stores/user';
  141. import { useWorkflowStore } from '@/stores/workflow';
  142. import { useShortcut, getShortcut } from '@/composable/shortcut';
  143. import { tasks } from '@/utils/shared';
  144. import { debounce, parseJSON, throttle } from '@/utils/helper';
  145. import { fetchApi } from '@/utils/api';
  146. import browser from 'webextension-polyfill';
  147. import EditorUtils from '@/utils/EditorUtils';
  148. import convertWorkflowData from '@/utils/convertWorkflowData';
  149. import WorkflowShare from '@/components/newtab/workflow/WorkflowShare.vue';
  150. import WorkflowEditor from '@/components/newtab/workflow/WorkflowEditor.vue';
  151. import WorkflowSettings from '@/components/newtab/workflow/WorkflowSettings.vue';
  152. import WorkflowEditBlock from '@/components/newtab/workflow/WorkflowEditBlock.vue';
  153. import WorkflowDataTable from '@/components/newtab/workflow/WorkflowDataTable.vue';
  154. import WorkflowGlobalData from '@/components/newtab/workflow/WorkflowGlobalData.vue';
  155. import WorkflowDetailsCard from '@/components/newtab/workflow/WorkflowDetailsCard.vue';
  156. import EditorLogs from '@/components/newtab/workflow/editor/EditorLogs.vue';
  157. import EditorLocalCtxMenu from '@/components/newtab/workflow/editor/EditorLocalCtxMenu.vue';
  158. import EditorLocalActions from '@/components/newtab/workflow/editor/EditorLocalActions.vue';
  159. const nanoid = customAlphabet('1234567890abcdef', 7);
  160. const { t } = useI18n();
  161. const store = useStore();
  162. const route = useRoute();
  163. const router = useRouter();
  164. const userStore = useUserStore();
  165. const workflowStore = useWorkflowStore();
  166. const editor = shallowRef(null);
  167. const state = reactive({
  168. showSidebar: true,
  169. dataChanged: false,
  170. workflowConverted: false,
  171. activeTab: route.query.tab || 'editor',
  172. });
  173. const modalState = reactive({
  174. name: '',
  175. show: false,
  176. });
  177. const editState = reactive({
  178. blockData: {},
  179. editing: false,
  180. });
  181. const autocompleteState = reactive({
  182. cache: new Map(),
  183. dataChanged: false,
  184. });
  185. const workflowPayload = {
  186. data: {},
  187. isUpdating: false,
  188. };
  189. const workflowModals = {
  190. table: {
  191. icon: 'riKey2Line',
  192. width: 'max-w-2xl',
  193. component: WorkflowDataTable,
  194. title: t('workflow.table.title'),
  195. docs: 'https://docs.automa.site/api-reference/table.html',
  196. },
  197. 'workflow-share': {
  198. icon: 'riShareLine',
  199. component: WorkflowShare,
  200. attrs: {
  201. blur: true,
  202. persist: true,
  203. customContent: true,
  204. },
  205. events: {
  206. close() {
  207. modalState.show = false;
  208. modalState.name = '';
  209. },
  210. publish() {
  211. modalState.show = false;
  212. modalState.name = '';
  213. },
  214. },
  215. },
  216. 'global-data': {
  217. width: 'max-w-2xl',
  218. icon: 'riDatabase2Line',
  219. component: WorkflowGlobalData,
  220. title: t('common.globalData'),
  221. docs: 'https://docs.automa.site/api-reference/global-data.html',
  222. },
  223. settings: {
  224. width: 'max-w-2xl',
  225. icon: 'riSettings3Line',
  226. component: WorkflowSettings,
  227. title: t('common.settings'),
  228. attrs: {
  229. customContent: true,
  230. },
  231. events: {
  232. close() {
  233. modalState.show = false;
  234. modalState.name = '';
  235. },
  236. },
  237. },
  238. };
  239. const workflow = computed(() => workflowStore.getById(route.params.id));
  240. const activeWorkflowModal = computed(
  241. () => workflowModals[modalState.name] || {}
  242. );
  243. provide('workflow', {
  244. editState,
  245. data: workflow,
  246. });
  247. const updateBlockData = debounce((data) => {
  248. const node = editor.value.getNode.value(editState.blockData.blockId);
  249. const dataCopy = JSON.parse(JSON.stringify(data));
  250. if (editState.blockData.itemId) {
  251. const itemIndex = node.data.blocks.findIndex(
  252. ({ itemId }) => itemId === editState.blockData.itemId
  253. );
  254. if (itemIndex === -1) return;
  255. node.data.blocks[itemIndex].data = dataCopy;
  256. } else {
  257. node.data = dataCopy;
  258. }
  259. editState.blockData.data = data;
  260. state.dataChanged = true;
  261. }, 250);
  262. const updateHostedWorkflow = throttle(async () => {
  263. if (!userStore.user || workflowPayload.isUpdating) return;
  264. const isHosted = userStore.hostedWorkflows[route.params.id];
  265. const isBackup = userStore.backupIds.includes(route.params.id);
  266. const workflowExist = workflowStore.getById(route.params.id);
  267. if (
  268. (!isBackup && !isHosted) ||
  269. (workflowExist && Object.keys(workflowPayload.data).length === 0)
  270. )
  271. return;
  272. workflowPayload.isUpdating = true;
  273. const delKeys = [
  274. 'id',
  275. 'pass',
  276. 'logs',
  277. 'trigger',
  278. 'createdAt',
  279. 'isDisabled',
  280. 'isProtected',
  281. ];
  282. delKeys.forEach((key) => {
  283. delete workflowPayload.data[key];
  284. });
  285. try {
  286. if (typeof workflowPayload.data.drawflow === 'string') {
  287. workflowPayload.data.drawflow = parseJSON(
  288. workflowPayload.data.drawflow,
  289. workflowPayload.data.drawflow
  290. );
  291. }
  292. const response = await fetchApi(`/me/workflows/${route.params.id}`, {
  293. method: 'PUT',
  294. keepalive: true,
  295. body: JSON.stringify({
  296. workflow: workflowPayload.data,
  297. }),
  298. });
  299. if (!response.ok) throw new Error(response.message);
  300. if (isBackup) {
  301. const result = await response.json();
  302. if (result.updatedAt) {
  303. await browser.storage.local.set({ lastBackup: result.updatedAt });
  304. }
  305. }
  306. workflowPayload.data = {};
  307. workflowPayload.isUpdating = false;
  308. } catch (error) {
  309. console.error(error);
  310. workflowPayload.isUpdating = false;
  311. }
  312. }, 5000);
  313. const onNodesChange = debounce((changes) => {
  314. changes.forEach(({ type, id }) => {
  315. if (type === 'remove') {
  316. if (editState.blockData.blockId === id) {
  317. editState.editing = false;
  318. editState.blockData = {};
  319. }
  320. state.dataChanged = true;
  321. }
  322. });
  323. }, 250);
  324. function toggleSidebar() {
  325. state.showSidebar = !state.showSidebar;
  326. localStorage.setItem('workflow:sidebar', state.showSidebar);
  327. }
  328. function initEditBlock(data) {
  329. const { editComponent, data: blockDefData } = tasks[data.id];
  330. const blockData = defu(data.data, blockDefData);
  331. editState.blockData = { ...data, editComponent, data: blockData };
  332. if (data.id === 'wait-connections') {
  333. const connections = editor.value.getEdges.value.reduce(
  334. (acc, { target, sourceNode, source }) => {
  335. if (target !== data.blockId) return acc;
  336. let name = t(`workflow.blocks.${sourceNode.label}.name`);
  337. const { description } = sourceNode.data;
  338. if (description) name += ` (${description})`;
  339. acc.push({
  340. name,
  341. id: source,
  342. });
  343. return acc;
  344. },
  345. []
  346. );
  347. editState.blockData.connections = connections;
  348. }
  349. editState.editing = true;
  350. }
  351. async function updateWorkflow(data) {
  352. try {
  353. await workflowStore.update({
  354. data,
  355. id: route.params.id,
  356. });
  357. workflowPayload.data = { ...workflowPayload.data, ...data };
  358. await updateHostedWorkflow();
  359. } catch (error) {
  360. console.error(error);
  361. }
  362. }
  363. function onActionUpdated({ data, changedIndicator }) {
  364. state.dataChanged = changedIndicator;
  365. workflowPayload.data = { ...workflowPayload.data, ...data };
  366. updateHostedWorkflow();
  367. }
  368. function onEditorInit(instance) {
  369. editor.value = instance;
  370. instance.onEdgesChange((changes) => {
  371. changes.forEach(({ type }) => {
  372. if (state.dataChanged) return;
  373. state.dataChanged = type !== 'select';
  374. });
  375. });
  376. instance.onEdgeDoubleClick(({ edge }) => {
  377. instance.removeEdges([edge]);
  378. });
  379. instance.onNodesChange(onNodesChange);
  380. }
  381. function clearHighlightedElements() {
  382. const elements = document.querySelectorAll(
  383. '.dropable-area__node, .dropable-area__handle'
  384. );
  385. elements.forEach((element) => {
  386. element.classList.remove('dropable-area__node');
  387. element.classList.remove('dropable-area__handle');
  388. });
  389. }
  390. function toggleHighlightElement({ target, elClass, classes }) {
  391. const targetEl = target.closest(elClass);
  392. if (targetEl) {
  393. targetEl.classList.add(classes);
  394. } else {
  395. const elements = document.querySelectorAll(`.${classes}`);
  396. elements.forEach((element) => {
  397. element.classList.remove(classes);
  398. });
  399. }
  400. }
  401. function onDragoverEditor({ target }) {
  402. toggleHighlightElement({
  403. target,
  404. elClass: '.vue-flow__handle.source',
  405. classes: 'dropable-area__handle',
  406. });
  407. if (!target.closest('.vue-flow__handle')) {
  408. toggleHighlightElement({
  409. target,
  410. elClass: '.vue-flow__node:not(.vue-flow__node-BlockGroup)',
  411. classes: 'dropable-area__node',
  412. });
  413. }
  414. }
  415. function onDropInEditor({ dataTransfer, clientX, clientY, target }) {
  416. const block = parseJSON(dataTransfer.getData('block'), null);
  417. if (!block) return;
  418. clearHighlightedElements();
  419. const nodeEl = EditorUtils.isNode(target);
  420. if (nodeEl) {
  421. EditorUtils.replaceNode(editor.value, { block, target: nodeEl });
  422. return;
  423. }
  424. const isTriggerExists =
  425. block.id === 'trigger' &&
  426. editor.value.getNodes.value.some((node) => node.label === 'trigger');
  427. if (isTriggerExists) return;
  428. const position = editor.value.project({ x: clientX - 360, y: clientY - 18 });
  429. const newNode = {
  430. position,
  431. id: nanoid(),
  432. label: block.id,
  433. data: block.data,
  434. type: block.component,
  435. };
  436. editor.value.addNodes([newNode]);
  437. const edgeEl = EditorUtils.isEdge(target);
  438. const handleEl = EditorUtils.isHandle(target);
  439. if (handleEl) {
  440. EditorUtils.appendNode(editor.value, {
  441. target: handleEl,
  442. nodeId: newNode.id,
  443. });
  444. } else if (edgeEl) {
  445. EditorUtils.insertBetweenNode(editor.value, {
  446. target: edgeEl,
  447. nodeId: newNode.id,
  448. outputs: block.outputs,
  449. });
  450. }
  451. if (block.fromGroup) {
  452. setTimeout(() => {
  453. const blockEl = document.querySelector(`[data-id="${newNode.id}"]`);
  454. blockEl?.setAttribute('group-item-id', block.itemId);
  455. }, 200);
  456. }
  457. state.dataChanged = true;
  458. }
  459. function copyElements(nodes, edges, initialPos) {
  460. const newIds = new Map();
  461. let firstNodePos = null;
  462. const newNodes = nodes.map(({ id, label, position, data, type }, index) => {
  463. const newNodeId = nanoid();
  464. const nodePos = {
  465. z: position.z || 0,
  466. y: position.y + 50,
  467. x: position.x + 50,
  468. };
  469. newIds.set(id, newNodeId);
  470. if (initialPos) {
  471. if (index === 0) {
  472. firstNodePos = {
  473. x: nodePos.x,
  474. y: nodePos.y,
  475. };
  476. initialPos = editor.value.project({
  477. y: initialPos.clientY,
  478. x: initialPos.clientX - 360,
  479. });
  480. Object.assign(nodePos, initialPos);
  481. } else {
  482. const xDistance = nodePos.x - firstNodePos.x;
  483. const yDistance = nodePos.y - firstNodePos.y;
  484. nodePos.x = initialPos.x + xDistance;
  485. nodePos.y = initialPos.y + yDistance;
  486. }
  487. }
  488. return {
  489. type,
  490. data,
  491. label,
  492. id: newNodeId,
  493. selected: true,
  494. position: nodePos,
  495. };
  496. });
  497. const newEdges = edges.reduce(
  498. (acc, { target, targetHandle, source, sourceHandle }) => {
  499. const targetId = newIds.get(target);
  500. const sourceId = newIds.get(source);
  501. if (!targetId || !sourceId) return acc;
  502. acc.push({
  503. selected: true,
  504. target: targetId,
  505. source: sourceId,
  506. id: `edge-${nanoid()}`,
  507. targetHandle: targetHandle.replace(target, targetId),
  508. sourceHandle: sourceHandle.replace(source, sourceId),
  509. });
  510. return acc;
  511. },
  512. []
  513. );
  514. return {
  515. nodes: newNodes,
  516. edges: newEdges,
  517. };
  518. }
  519. function duplicateElements({ nodes, edges }) {
  520. const selectedNodes = editor.value.getSelectedNodes.value;
  521. const selectedEdges = editor.value.getSelectedEdges.value;
  522. const { edges: newEdges, nodes: newNodes } = copyElements(
  523. nodes || selectedNodes,
  524. edges || selectedEdges
  525. );
  526. editor.value.removeSelectedNodes(selectedNodes);
  527. editor.value.removeSelectedEdges(selectedEdges);
  528. editor.value.addNodes(newNodes);
  529. editor.value.addEdges(newEdges);
  530. }
  531. function copySelectedElements(data = {}) {
  532. store.copiedEls.nodes = data.nodes || editor.value.getSelectedNodes.value;
  533. store.copiedEls.edges = data.edges || editor.value.getSelectedEdges.value;
  534. }
  535. function pasteCopiedElements(position) {
  536. editor.value.removeSelectedNodes(editor.value.getSelectedNodes.value);
  537. editor.value.removeSelectedEdges(editor.value.getSelectedEdges.value);
  538. const { nodes, edges } = copyElements(
  539. store.copiedEls.nodes,
  540. store.copiedEls.edges,
  541. position
  542. );
  543. editor.value.addNodes(nodes);
  544. editor.value.addEdges(edges);
  545. }
  546. function onKeydown({ ctrlKey, metaKey, key }) {
  547. const command = (keyName) => (ctrlKey || metaKey) && keyName === key;
  548. if (command('c')) {
  549. copySelectedElements();
  550. } else if (command('v')) {
  551. pasteCopiedElements();
  552. }
  553. }
  554. const shortcut = useShortcut([
  555. getShortcut('editor:toggle-sidebar', toggleSidebar),
  556. getShortcut('editor:duplicate-block', duplicateElements),
  557. ]);
  558. /* eslint-disable consistent-return */
  559. onBeforeRouteLeave(() => {
  560. updateHostedWorkflow();
  561. if (!state.dataChanged) return;
  562. const confirm = window.confirm(t('message.notSaved'));
  563. if (!confirm) return false;
  564. });
  565. onMounted(() => {
  566. if (!workflow.value) {
  567. router.replace('/');
  568. return null;
  569. }
  570. state.showSidebar =
  571. JSON.parse(localStorage.getItem('workflow:sidebar')) ?? true;
  572. const convertedData = convertWorkflowData(workflow.value);
  573. updateWorkflow({ drawflow: convertedData.drawflow }).then(() => {
  574. state.workflowConverted = true;
  575. });
  576. window.onbeforeunload = () => {
  577. updateHostedWorkflow();
  578. if (state.dataChanged) {
  579. return t('message.notSaved');
  580. }
  581. };
  582. window.addEventListener('keydown', onKeydown);
  583. });
  584. onBeforeUnmount(() => {
  585. window.onbeforeunload = null;
  586. window.removeEventListener('keydown', onKeydown);
  587. });
  588. </script>
  589. <style>
  590. .vue-flow,
  591. .editor-tab {
  592. width: 100%;
  593. height: 100%;
  594. }
  595. .vue-flow__node {
  596. @apply rounded-lg;
  597. }
  598. .dropable-area__node,
  599. .dropable-area__handle {
  600. @apply ring-4;
  601. }
  602. </style>